--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e3687e3fb511df6154062df5ce6b6956da4ebe46
Parents : b05bf1b
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T02:30:52-05:00
feat(repository-server): add repository server management and HTTP endpoints for file operations
Changes
3 files changed, 293 insertions(+), 1 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 78667720..4ca3e6a7 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -93,7 +93,7 @@ from meshchatx.src.backend.lxmf_utils import (
is_user_facing_lxmf_payload,
lxmf_fields_are_columba_reaction,
)
-from meshchatx.src.backend.map_manager import TRANSPARENT_TILE
+from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, TRANSPARENT_TILE
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.meshchat_utils import (
convert_db_favourite_to_dict,
@@ -357,6 +357,19 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.docs_manager = value
+ @property
+ def repository_server_manager(self):
+ return (
+ self.current_context.repository_server_manager
+ if self.current_context
+ else None
+ )
+
+ @repository_server_manager.setter
+ def repository_server_manager(self, value):
+ if self.current_context:
+ self.current_context.repository_server_manager = value
+
@property
def nomadnet_manager(self):
return self.current_context.nomadnet_manager if self.current_context else None
@@ -5413,6 +5426,141 @@ class ReticulumMeshChat:
return web.json_response(content)
+ # repository server (wheels + uploads; optional in-process plain HTTP)
+ @routes.get("/api/v1/repository-server/status")
+ async def repository_server_status(_request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ return web.json_response(mgr.status())
+
+ @routes.get("/api/v1/repository-server/list")
+ async def repository_server_list(_request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ return web.json_response(mgr.list_entries())
+
+ @routes.post("/api/v1/repository-server/upload")
+ async def repository_server_upload(request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ try:
+ reader = await request.multipart()
+ field = await reader.next()
+ if not field or field.name != "file":
+ return web.json_response(
+ {"error": "No file field in multipart request"},
+ status=400,
+ )
+ filename = field.filename or "upload.bin"
+ data = await field.read()
+ ok, err = mgr.save_upload(filename, data)
+ if not ok:
+ return web.json_response(
+ {"success": False, "error": err}, status=400
+ )
+ return web.json_response({"success": True})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ @routes.delete("/api/v1/repository-server/upload/{name}")
+ async def repository_server_delete_upload(request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ name = request.match_info.get("name") or ""
+ ok, err = mgr.delete_upload(name)
+ if not ok:
+ code = 404 if err == "not_found" else 400
+ return web.json_response({"success": False, "error": err}, status=code)
+ return web.json_response({"success": True})
+
+ @routes.post("/api/v1/repository-server/refresh-bundled")
+ async def repository_server_refresh_bundled(_request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ try:
+ result = await asyncio.to_thread(mgr.refresh_bundled_wheels)
+ return web.json_response(result)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ @routes.post("/api/v1/repository-server/http/start")
+ async def repository_server_http_start(request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+ host = data.get("host")
+ port = data.get("port")
+ port_int = None
+ if port is not None:
+ try:
+ port_int = int(port)
+ except (TypeError, ValueError):
+ return web.json_response(
+ {"ok": False, "error": "invalid_port"}, status=400
+ )
+ try:
+ result = await asyncio.to_thread(
+ mgr.start_http_server,
+ str(host).strip() if host is not None else None,
+ port_int,
+ )
+ return web.json_response(result)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ @routes.post("/api/v1/repository-server/http/stop")
+ async def repository_server_http_stop(_request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ try:
+ result = await asyncio.to_thread(mgr.stop_http_server)
+ return web.json_response(result)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ @routes.post("/api/v1/repository-server/http/restart")
+ async def repository_server_http_restart(request):
+ mgr = self.repository_server_manager
+ if not mgr:
+ return web.json_response({"error": "Unavailable"}, status=503)
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+ host = data.get("host")
+ port = data.get("port")
+ port_int = None
+ if port is not None:
+ try:
+ port_int = int(port)
+ except (TypeError, ValueError):
+ return web.json_response(
+ {"ok": False, "error": "invalid_port"}, status=400
+ )
+ try:
+ result = await asyncio.to_thread(
+ mgr.restart_http_server,
+ str(host).strip() if host is not None else None,
+ port_int,
+ )
+ return web.json_response(result)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
# export docs
@routes.get("/api/v1/docs/export")
async def docs_export(request):
@@ -11277,6 +11425,23 @@ class ReticulumMeshChat:
if not bbox or len(bbox) != 4:
return web.json_response({"error": "Invalid bbox"}, status=400)
+ tile_count = self.map_manager.count_export_tiles(
+ bbox,
+ min_zoom,
+ max_zoom,
+ )
+ if tile_count > MAX_EXPORT_TILES:
+ return web.json_response(
+ {
+ "error": (
+ f"Export would download {tile_count} tiles; "
+ f"maximum allowed is {MAX_EXPORT_TILES}. "
+ "Shrink the area or lower max zoom."
+ ),
+ },
+ status=400,
+ )
+
export_id = secrets.token_hex(8)
self.map_manager.start_export(export_id, bbox, min_zoom, max_zoom, name)
@@ -13074,6 +13239,61 @@ class ReticulumMeshChat:
duplicate_signal = "duplicate_lxm"
try:
+ uri_raw = uri.strip()
+ lu = uri_raw.lower()
+ if lu.startswith("meshchatx://map") or lu.startswith("meshchat://map"):
+ from urllib.parse import parse_qsl, urlparse
+
+ parsed = urlparse(uri_raw)
+ q = dict(parse_qsl(parsed.query, keep_blank_values=True))
+ try:
+ lat = float(q.get("lat", "") or "")
+ lon = float(q.get("lon", "") or "")
+ except (TypeError, ValueError):
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "lxm.ingest_uri.result",
+ "status": "error",
+ "message": "Invalid map link: lat and lon must be numbers.",
+ },
+ ),
+ ),
+ )
+ return
+ zraw = q.get("z") or q.get("zoom") or "10"
+ try:
+ zoom = int(float(zraw))
+ except (TypeError, ValueError):
+ zoom = 10
+ zoom = max(0, min(22, zoom))
+ layers = (q.get("layers") or "").strip()
+ label = (q.get("label") or "").strip()
+ mq = {
+ "lat": lat,
+ "lon": lon,
+ "zoom": zoom,
+ }
+ if layers:
+ mq["layers"] = layers
+ if label:
+ mq["label"] = label
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "lxm.ingest_uri.result",
+ "status": "success",
+ "message": "Opening map view.",
+ "ingest_type": "map_view",
+ "map_query": mq,
+ },
+ ),
+ ),
+ )
+ return
+
# Columba-style contact sharing URI:
# lxma://<destination_hash_hex>:<public_key_hex>
if uri.lower().startswith("lxma://"):
diff --git a/meshchatx/repository_http_standalone.py b/meshchatx/repository_http_standalone.py
new file mode 100644
index 00000000..33c58e58
--- /dev/null
+++ b/meshchatx/repository_http_standalone.py
@@ -0,0 +1,61 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Plain HTTP file server for a MeshChatX repository-server directory."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import socketserver
+
+from meshchatx.src.backend.repository_server_manager import (
+ make_repository_http_request_handler,
+)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Serve MeshChatX repository-server files over HTTP without TLS.",
+ )
+ parser.add_argument(
+ "--directory",
+ required=True,
+ help="Path to the identity's repository-server folder (contains uploads/ and bundled/).",
+ )
+ parser.add_argument(
+ "--host", default="0.0.0.0", help="Bind address (default 0.0.0.0)."
+ )
+ parser.add_argument(
+ "--port", type=int, default=8787, help="TCP port (default 8787)."
+ )
+ parser.add_argument(
+ "--public-dir",
+ default=None,
+ help="MeshChatX public directory (for repository-server-index.html when not next to source).",
+ )
+ args = parser.parse_args()
+ root = os.path.abspath(args.directory)
+ if not os.path.isdir(root):
+ print(f"Not a directory: {root}", flush=True)
+ return 2
+
+ pub = os.path.abspath(args.public_dir) if args.public_dir else None
+ handler_cls = make_repository_http_request_handler(root, public_dir=pub)
+ httpd = socketserver.ThreadingTCPServer((args.host, args.port), handler_cls)
+ httpd.allow_reuse_address = True
+ print(
+ f"Serving {root} at http://{args.host}:{args.port}/ (uploads/ and bundled/ subdirs)",
+ flush=True,
+ )
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ print("Stopping.", flush=True)
+ return 0
+ finally:
+ httpd.server_close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 9e342241..71842e2c 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -16,6 +16,7 @@ from meshchatx.src.backend.community_interfaces import CommunityInterfacesManage
from meshchatx.src.backend.config_manager import ConfigManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.docs_manager import DocsManager
+from meshchatx.src.backend.repository_server_manager import RepositoryServerManager
from meshchatx.src.backend.forwarding_manager import ForwardingManager
from meshchatx.src.backend.integrity_manager import IntegrityManager
from meshchatx.src.backend.map_manager import MapManager
@@ -70,6 +71,7 @@ class IdentityContext:
self.archiver_manager = None
self.map_manager = None
self.docs_manager = None
+ self.repository_server_manager = None
self.nomadnet_manager = None
self.message_router = None
self.telephone_manager = None
@@ -178,6 +180,10 @@ class IdentityContext:
),
storage_dir=self.storage_path,
)
+ self.repository_server_manager = RepositoryServerManager(
+ self.storage_path,
+ public_dir=self.app.get_public_path(),
+ )
self.nomadnet_manager = NomadNetworkManager(
self.config,
self.archiver_manager,
@@ -581,6 +587,11 @@ class IdentityContext:
if self.docs_manager:
self.docs_manager = None
+ if self.repository_server_manager:
+ with contextlib.suppress(Exception):
+ self.repository_server_manager.stop_http_server()
+ self.repository_server_manager = None
+
if self.nomadnet_manager:
self.nomadnet_manager = None
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────